/** * Copyright (c) 2010 Daniel Murphy, Stefan Brozinski * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /** * Created at Jul 20, 2010, 4:04:22 AM */ package tk.wurst_client.analytics; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.Proxy.Type; import java.net.SocketAddress; import java.net.URL; import java.util.LinkedList; import java.util.Scanner; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.MatchResult; /** * Common tracking calls are implemented as methods, but if you want to control * what data to send, then use {@link #makeCustomRequest(AnalyticsRequestData)}. * If you are making custom calls, the only requirements are: * <ul> * <li>If you are tracking an event, * {@link AnalyticsRequestData#setEventCategory(String)} and * {@link AnalyticsRequestData#setEventAction(String)} must both be populated.</li> * <li>If you are not tracking an event, * {@link AnalyticsRequestData#setPageURL(String)} must be populated</li> * </ul> * See the <a * href=http://code.google.com/intl/en-US/apis/analytics/docs/tracking * /gaTrackingTroubleshooting.html#gifParameters> * Google Troubleshooting Guide</a> for more info on the tracking parameters * (although it doesn't seem to be fully updated). * <p> * The tracker can operate in three modes: * <ul> * <li>synchronous mode: The HTTP request is sent to GA immediately, before the * track method returns. This may slow your application down if GA doesn't * respond fast. * <li>multi-thread mode: Each track method call creates a new short-lived * thread that sends the HTTP request to GA in the background and terminates. * <li>single-thread mode (the default): The track method stores the request in * a FIFO and returns immediately. A single long-lived background thread * consumes the FIFO content and sends the HTTP requests to GA. * </ul> * </p> * <p> * To halt the background thread safely, use the call * {@link #stopBackgroundThread(long)}, where the parameter is the timeout to * wait for any remaining queued tracking calls to be made. Keep in mind that if * new tracking requests are made after the thread is stopped, they will just be * stored in the queue, and will not be sent to GA until the thread is started * again with {@link #startBackgroundThread()} (This is assuming you are in * single-threaded mode to begin with). * </p> * * @author Daniel Murphy, Stefan Brozinski */ public class JGoogleAnalyticsTracker { public static enum DispatchMode { /** * Each tracking call will wait until the http request * completes before returning */ SYNCHRONOUS, /** * Each tracking call spawns a new thread to make the http request */ MULTI_THREAD, /** * Each tracking request is added to a queue, and a single dispatch * thread makes the requests. */ SINGLE_THREAD } private static Logger logger = Logger .getLogger(JGoogleAnalyticsTracker.class.getName()); private static final ThreadGroup asyncThreadGroup = new ThreadGroup( "Async Google Analytics Threads"); private static long asyncThreadsRunning = 0; private static Proxy proxy = Proxy.NO_PROXY; private static LinkedList<String> fifo = new LinkedList<String>(); private static volatile Thread backgroundThread = null; // the thread used // in // 'queued' mode. private static boolean backgroundThreadMayRun = false; static { asyncThreadGroup.setMaxPriority(Thread.MIN_PRIORITY); asyncThreadGroup.setDaemon(true); } public static enum GoogleAnalyticsVersion { V_4_7_2 } private GoogleAnalyticsVersion gaVersion; private AnalyticsConfigData configData; private IGoogleAnalyticsURLBuilder builder; private DispatchMode mode; private boolean enabled; public JGoogleAnalyticsTracker(AnalyticsConfigData argConfigData, GoogleAnalyticsVersion argVersion) { this(argConfigData, argVersion, DispatchMode.SINGLE_THREAD); } public JGoogleAnalyticsTracker(AnalyticsConfigData argConfigData, GoogleAnalyticsVersion argVersion, DispatchMode argMode) { gaVersion = argVersion; configData = argConfigData; createBuilder(); enabled = true; setDispatchMode(argMode); } /** * Sets the dispatch mode * * @see DispatchMode * @param argMode * the mode to to put the tracker in. If this is null, the * tracker * defaults to {@link DispatchMode#SINGLE_THREAD} */ public void setDispatchMode(DispatchMode argMode) { if(argMode == null) argMode = DispatchMode.SINGLE_THREAD; if(argMode == DispatchMode.SINGLE_THREAD) startBackgroundThread(); mode = argMode; } /** * Gets the current dispatch mode. Default is * {@link DispatchMode#SINGLE_THREAD}. * * @see DispatchMode * @return */ public DispatchMode getDispatchMode() { return mode; } /** * Convenience method to check if the tracker is in synchronous mode. * * @return */ public boolean isSynchronous() { return mode == DispatchMode.SYNCHRONOUS; } /** * Convenience method to check if the tracker is in single-thread mode * * @return */ public boolean isSingleThreaded() { return mode == DispatchMode.SINGLE_THREAD; } /** * Convenience method to check if the tracker is in multi-thread mode * * @return */ public boolean isMultiThreaded() { return mode == DispatchMode.MULTI_THREAD; } /** * Resets the session cookie. */ public void resetSession() { builder.resetSession(); } /** * Sets if the api dispatches tracking requests. * * @param argEnabled */ public void setEnabled(boolean argEnabled) { enabled = argEnabled; } /** * If the api is dispatching tracking requests (default of true). * * @return */ public boolean isEnabled() { return enabled; } /** * Define the proxy to use for all GA tracking requests. * <p> * Call this static method early (before creating any tracking requests). * * @param argProxy * The proxy to use */ public static void setProxy(Proxy argProxy) { proxy = argProxy != null ? argProxy : Proxy.NO_PROXY; } /** * Define the proxy to use for all GA tracking requests. * <p> * Call this static method early (before creating any tracking requests). * * @param proxyAddr * "addr:port" of the proxy to use; may also be given as URL * ("http://addr:port/"). */ public static void setProxy(String proxyAddr) { if(proxyAddr != null) { Scanner s = new Scanner(proxyAddr); // Split into "proxyAddr:proxyPort". proxyAddr = null; int proxyPort = 8080; try { s.findInLine("(http://|)([^:/]+)(:|)([0-9]*)(/|)"); MatchResult m = s.match(); if(m.groupCount() >= 2) proxyAddr = m.group(2); if(m.groupCount() >= 4 && !(m.group(4).length() == 0)) proxyPort = Integer.parseInt(m.group(4)); }finally { s.close(); } if(proxyAddr != null) { SocketAddress sa = new InetSocketAddress(proxyAddr, proxyPort); setProxy(new Proxy(Type.HTTP, sa)); } } } /** * Wait for background tasks to complete. * <p> * This works in queued and asynchronous mode. * * @param timeoutMillis * The maximum number of milliseconds to wait. */ public static void completeBackgroundTasks(long timeoutMillis) { boolean fifoEmpty = false; boolean asyncThreadsCompleted = false; long absTimeout = System.currentTimeMillis() + timeoutMillis; while(System.currentTimeMillis() < absTimeout) { synchronized(fifo) { fifoEmpty = fifo.size() == 0; } synchronized(JGoogleAnalyticsTracker.class) { asyncThreadsCompleted = asyncThreadsRunning == 0; } if(fifoEmpty && asyncThreadsCompleted) break; try { Thread.sleep(100); }catch(InterruptedException e) { break; } } } /** * Tracks a page view. * * @param argPageURL * required, Google won't track without it. Ex: * <code>"org/me/javaclass.java"</code>, or anything you want as * the page url. * @param argPageTitle * content title * @param argHostName * the host name for the url */ public void trackPageView(String argPageURL, String argPageTitle, String argHostName) { if(argPageURL == null) throw new IllegalArgumentException( "Page URL cannot be null, Google will not track the data."); AnalyticsRequestData data = new AnalyticsRequestData(); data.setHostName(argHostName); data.setPageTitle(argPageTitle); data.setPageURL(argPageURL); makeCustomRequest(data); } /** * Tracks a page view. * * @param argPageURL * required, Google won't track without it. Ex: * <code>"org/me/javaclass.java"</code>, or anything you want as * the page url. * @param argPageTitle * content title * @param argHostName * the host name for the url * @param argReferrerSite * site of the referrer. ex, www.dmurph.com * @param argReferrerPage * page of the referrer. ex, /mypage.php */ public void trackPageViewFromReferrer(String argPageURL, String argPageTitle, String argHostName, String argReferrerSite, String argReferrerPage) { if(argPageURL == null) throw new IllegalArgumentException( "Page URL cannot be null, Google will not track the data."); AnalyticsRequestData data = new AnalyticsRequestData(); data.setHostName(argHostName); data.setPageTitle(argPageTitle); data.setPageURL(argPageURL); data.setReferrer(argReferrerSite, argReferrerPage); makeCustomRequest(data); } /** * Tracks a page view. * * @param argPageURL * required, Google won't track without it. Ex: * <code>"org/me/javaclass.java"</code>, or anything you want as * the page url. * @param argPageTitle * content title * @param argHostName * the host name for the url * @param argSearchSource * source of the search engine. ex: google * @param argSearchKeywords * the keywords of the search. ex: java google analytics tracking * utility */ public void trackPageViewFromSearch(String argPageURL, String argPageTitle, String argHostName, String argSearchSource, String argSearchKeywords) { if(argPageURL == null) throw new IllegalArgumentException( "Page URL cannot be null, Google will not track the data."); AnalyticsRequestData data = new AnalyticsRequestData(); data.setHostName(argHostName); data.setPageTitle(argPageTitle); data.setPageURL(argPageURL); data.setSearchReferrer(argSearchSource, argSearchKeywords); makeCustomRequest(data); } /** * Tracks an event. To provide more info about the page, use * {@link #makeCustomRequest(AnalyticsRequestData)}. * * @param argCategory * @param argAction */ public void trackEvent(String argCategory, String argAction) { trackEvent(argCategory, argAction, null, null); } /** * Tracks an event. To provide more info about the page, use * {@link #makeCustomRequest(AnalyticsRequestData)}. * * @param argCategory * @param argAction * @param argLabel */ public void trackEvent(String argCategory, String argAction, String argLabel) { trackEvent(argCategory, argAction, argLabel, null); } /** * Tracks an event. To provide more info about the page, use * {@link #makeCustomRequest(AnalyticsRequestData)}. * * @param argCategory * required * @param argAction * required * @param argLabel * optional * @param argValue * optional */ public void trackEvent(String argCategory, String argAction, String argLabel, Integer argValue) { AnalyticsRequestData data = new AnalyticsRequestData(); data.setEventCategory(argCategory); data.setEventAction(argAction); data.setEventLabel(argLabel); data.setEventValue(argValue); makeCustomRequest(data); } /** * Makes a custom tracking request based from the given data. * * @param argData * @throws NullPointerException * if argData is null or if the URL builder is null */ public synchronized void makeCustomRequest(AnalyticsRequestData argData) { if(!enabled) { logger.log(Level.CONFIG, "Ignoring tracking request, enabled is false"); return; } if(argData == null) throw new NullPointerException("Data cannot be null"); if(builder == null) throw new NullPointerException("Class was not initialized"); final String url = builder.buildURL(argData); final String userAgent = configData.getUserAgent(); switch(mode) { case MULTI_THREAD: Thread t = new Thread(asyncThreadGroup, "AnalyticsThread-" + asyncThreadGroup.activeCount()) { @Override public void run() { synchronized(JGoogleAnalyticsTracker.class) { asyncThreadsRunning++; } try { dispatchRequest(url, userAgent); }finally { synchronized(JGoogleAnalyticsTracker.class) { asyncThreadsRunning--; } } } }; t.setDaemon(true); t.start(); break; case SYNCHRONOUS: dispatchRequest(url, userAgent); break; default: // in case it's null, we default to the single-thread synchronized(fifo) { fifo.addLast(url); fifo.notify(); } if(!backgroundThreadMayRun) logger .log( Level.SEVERE, "A tracker request has been added to the queue but the background thread isn't running.", url); break; } } private static void dispatchRequest(String argURL, String userAgent) { try { URL url = new URL(argURL); HttpURLConnection connection = (HttpURLConnection)url.openConnection(proxy); connection.setRequestMethod("GET"); connection.setInstanceFollowRedirects(true); if(userAgent != null) connection.addRequestProperty("User-Agent", userAgent); connection.connect(); int responseCode = connection.getResponseCode(); if(responseCode != HttpURLConnection.HTTP_OK) logger.log(Level.SEVERE, "JGoogleAnalyticsTracker: Error requesting url '" + argURL + "', received response code " + responseCode); else logger.log(Level.CONFIG, "JGoogleAnalyticsTracker: Tracking success for url '" + argURL + "'"); }catch(Exception e) { logger.log(Level.SEVERE, "Error making tracking request", e); } } private void createBuilder() { switch(gaVersion) { case V_4_7_2: builder = new GoogleAnalyticsV4_7_2(configData); break; default: builder = new GoogleAnalyticsV4_7_2(configData); break; } } /** * If the background thread for 'queued' mode is not running, start it now. */ private synchronized void startBackgroundThread() { if(backgroundThread == null) { backgroundThreadMayRun = true; backgroundThread = new Thread(asyncThreadGroup, "AnalyticsBackgroundThread") { @Override public void run() { logger.log(Level.CONFIG, "AnalyticsBackgroundThread started"); while(backgroundThreadMayRun) try { String url = null; synchronized(fifo) { if(fifo.isEmpty()) fifo.wait(); if(!fifo.isEmpty()) // Get a reference to the oldest element // in // the FIFO, but leave it in the FIFO // until // it is processed. url = fifo.getFirst(); } if(url != null) try { dispatchRequest(url, configData.getUserAgent()); }finally { // Now that we have completed the HTTP // request to GA, remove the element // from // the FIFO. synchronized(fifo) { fifo.removeFirst(); } } }catch(Exception e) { logger.log(Level.SEVERE, "Got exception from dispatch thread", e); } } }; // Don't prevent the application from terminating. // Use completeBackgroundTasks() before exit if you want to ensure // that all pending GA requests are sent. backgroundThread.setDaemon(true); backgroundThread.start(); } } /** * Stop the long-lived background thread. * <p> * This method is needed for debugging purposes only. Calling it in an * application is not really required: The background thread will terminate * automatically when the application exits. * * @param timeoutMillis * If nonzero, wait for thread completion before returning. */ public synchronized static void stopBackgroundThread(long timeoutMillis) { backgroundThreadMayRun = false; fifo.notify(); if(backgroundThread != null && timeoutMillis > 0) { try { backgroundThread.join(timeoutMillis); }catch(InterruptedException e) {} backgroundThread = null; } } }